Skip to content

perf(terminal): coalesce PTY output into one xterm.write per animation frame (v2 transport) - #5255

Merged
Kitenite merged 2 commits into
ParadevAI:mainfrom
thebrownproject:perf/v2-terminal-write-coalescing
Jun 22, 2026
Merged

Kitenite merged 2 commits into
ParadevAI:mainfrom
thebrownproject:perf/v2-terminal-write-coalescing

Conversation

@thebrownproject

@thebrownproject thebrownproject commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Addresses #2241 / #2244 for the v2 WebSocket terminal path.

Agent CLIs — Claude Code especially — emit full-screen repaints as many small PTY chunks. The v2 transport currently calls terminal.write() once per WebSocket frame with no batching anywhere in the pipeline (unlike the v1 IPC path, which at least has 16ms batching in the main process via DataBatcher). Each chunk triggers an xterm parse/render cycle, so during streaming output the renderer falls behind and scrolling turns visibly janky.

Real-world report: running Claude Code inside the Superset 2 beta, scrolling is laggy while the same session in a native GPU terminal (Warp) is smooth. Markdown/code views in Superset scroll fine — only the embedded terminal struggles, and only under streaming TUI output.

Fix

Adds a small write-coalescer module and wires it into terminal-ws-transport:

  • Incoming binary PTY frames are buffered and written to xterm as one write() per requestAnimationFrame, aligning parse/render cost with the display refresh rate regardless of chunk count — the fix suggested in [perf] Terminal stream events lack client-side batching before xterm.write() #2241, applied to the v2 path.
  • Ordering is preserved: control messages that write to the terminal (exit notice, invalid-payload line) and socket close flush pending bytes synchronously first.
  • Memory is bounded: a 1MB pending cap flushes synchronously if rAF stalls (e.g. hidden window, where Electron throttles the renderer).
  • Coalescer is recreated per connect() and disposed (with a final flush) on disconnect/disposeTransport.

Tests

  • 7 unit tests for the coalescer (same-frame coalescing, re-scheduling, sync flush, byte cap, dispose semantics, byte-order preservation).
  • 3 transport-level tests (binary frames coalesce into one write; exit notice ordering; flush on socket close).
  • bun test src/renderer/lib/terminal/: 234 pass / 1 fail — the failure ("recovers a half-open socket after the machine resumes from sleep") also fails on an untouched checkout of main in my environment, so it appears unrelated.
  • Biome clean.

Notes

  • v1 (useTerminalStream) renderer-side coalescing is a natural follow-up using the same module; left out to keep this reviewable.
  • The other half of the lag story for multi-tab users appears to be the module-level suggestedRendererType WebGL fallback (one context loss permanently downgrades all terminals to the DOM renderer until restart) — happy to look at that separately if useful.

Open in Stage

Summary by cubic

Coalesces PTY output in the v2 WebSocket terminal path into one xterm.write() per animation frame to reduce parse/render churn and smooth scrolling. Addresses #2241 / #2244 by aligning terminal work with display refresh.

  • New Features
    • Added a write-coalescer that batches incoming binary PTY frames and writes once per requestAnimationFrame.
    • Preserves output order by flushing before control messages that write to the terminal (exit notice, invalid-payload line) and on socket close; does not flush for non-writing controls.
    • Bounded memory with a 1 MB cap; cancels any scheduled frame on sync flush; coalescer is recreated on connect() and disposed on disconnect/disposeTransport.

Written for commit 895bb2e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved terminal PTY output handling by batching writes per animation frame, preserving output ordering during control messages and ensuring queued output flushes correctly on disconnect/close.
  • New Features

    • Added a write coalescing utility to efficiently batch terminal writes and cap queued data to avoid excessive buffering.
  • Tests

    • Added deterministic test suites covering write coalescing behavior and PTY output coalescing/flush ordering in the WebSocket transport.

Agent CLIs (Claude Code especially) emit full-screen repaints as many
small PTY chunks. The v2 WebSocket transport wrote each chunk to xterm
individually, triggering a parse/render cycle per chunk - the renderer
falls behind during streaming output and scrolling turns janky.

Batch incoming binary frames in a requestAnimationFrame-aligned
coalescer so xterm processes one write per displayed frame. Control
messages and socket close flush synchronously first, so exit notices
and error lines stay ordered with PTY output. A 1MB pending cap bounds
memory when rAF stalls (hidden window).

Addresses ParadevAI#2241 for the v2 transport path (the v1 IPC path already has
16ms batching in the main process via DataBatcher; its renderer-side
coalescing can follow the same pattern).
@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 517644c4-8bb6-44dd-831a-55a0836ecd35

📥 Commits

Reviewing files that changed from the base of the PR and between 3e0be04 and 895bb2e.

📒 Files selected for processing (2)
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts

📝 Walkthrough

Walkthrough

This PR introduces PTY output write coalescing to batch terminal writes and preserve output ordering. A new WriteCoalescer utility batches incoming byte chunks and flushes them once per animation frame; TerminalTransport routes binary PTY frames into the coalescer instead of writing directly, and flushes queued bytes before processing control messages and on socket close.

Changes

PTY Output Write Coalescing

Layer / File(s) Summary
Write Coalescer Implementation and Tests
apps/desktop/src/renderer/lib/terminal/write-coalescer.ts, apps/desktop/src/renderer/lib/terminal/write-coalescer.test.ts
New WriteCoalescer interface with push() for queueing, flushSync() for immediate writes, and dispose() for cleanup. createWriteCoalescer() batches queued Uint8Array chunks and schedules flushes via requestAnimationFrame, respects MAX_PENDING_BYTES to force immediate flush when throttled, and preserves byte order. Tests cover per-frame coalescing, flush scheduling, cap-triggered flushing, flushSync() semantics, and post-disposal behavior.
TerminalTransport Coalescer Integration
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts, apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts
TerminalTransport adds _writeCoalescer field, routes binary PTY frames into the coalescer, flushes queued bytes synchronously before processing JSON control messages to preserve ordering, and flushes on WebSocket close. Coalescer is created on connect() and disposed during disconnect() and disposeTransport() to prevent leaks. Integration tests verify coalescing multiple frames into one terminal.write per animation frame, flushing before exit notices, and flushing on socket close.

Sequence Diagram(s)

sequenceDiagram
  participant WebSocket
  participant Transport
  participant WriteCoalescer
  participant Terminal
  WebSocket->>Transport: message (binary PTY frame 1)
  Transport->>WriteCoalescer: push(ArrayBuffer)
  WebSocket->>Transport: message (binary PTY frame 2)
  Transport->>WriteCoalescer: push(ArrayBuffer)
  WebSocket->>Transport: message (JSON control: title/exit)
  Transport->>WriteCoalescer: flushSync()
  WriteCoalescer->>Terminal: write(coalesced bytes)
  Transport->>Terminal: writeln(control message)
  WebSocket->>Transport: close event
  Transport->>WriteCoalescer: flushSync()
  WriteCoalescer->>Terminal: write(remaining bytes)
  Transport->>Transport: mark disconnected
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • superset-sh/superset#4896: Both PRs modify terminal-ws-transport.ts binary PTY message handling; the coalescer changes may impact output-ack flow-control logic that depends on terminal.write callback timing.
  • superset-sh/superset#4535: Both PRs modify terminal-ws-transport.ts WebSocket binary-frame handling and output write path, so changes overlap at the same code-level area.

Poem

🐰 Frame by frame, the bytes align,
Coalesced and queued so fine—
When the animation frame does fire,
PTY output climbs ever higher!
No jittery scrolls, just smooth delight,
The terminal's never looked so right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the primary change: implementing PTY output coalescing for the v2 terminal transport to improve performance.
Description check ✅ Passed The description covers all required sections with detailed context, problem statement, solution, testing results, and additional notes, exceeding the template baseline.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ghost

ghost commented Jun 12, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 3 individual chapters for you:

Title
1 Implement the write-coalescer utility
2 Integrate coalescer into terminal-ws-transport
3 Verify transport-level coalescing behavior
Open in Stage

Chapters generated by Stage for commit 3e0be04 on Jun 12, 2026 11:08pm UTC.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Architecture diagram
sequenceDiagram
    participant WS as WebSocket
    participant Transport as Terminal WS Transport
    participant Coalescer as Write Coalescer
    participant XTerm as XTerm Terminal

    Note over WS,XTerm: PTY Output Flow (v2 WebSocket Transport)

    loop Per WebSocket Message
        WS->>Transport: binary frame (PTY chunk)
        Transport->>Coalescer: push(chunk)
        Coalescer->>Coalescer: buffer chunk, track pendingBytes

        alt pendingBytes > 1MB cap
            Coalescer->>Coalescer: flushSync()
            Coalescer->>XTerm: write(combined buffer)
        else rAF not scheduled
            Coalescer->>Coalescer: requestAnimationFrame()
        end
    end

    Note over Coalescer: requestAnimationFrame fires
    Coalescer->>Coalescer: flushSync() (combine all pending)
    Coalescer->>XTerm: write(combined buffer)

    Note over WS,XTerm: Control Message Ordering

    WS->>Transport: control message (exit, error)
    Transport->>Coalescer: flushSync()
    Coalescer-->>XTerm: pending PTY bytes written
    Transport->>XTerm: writeln("[terminal] exited...")

    Note over WS,XTerm: Disconnect / Dispose

    WS->>WS: socket close event
    Transport->>Coalescer: flushSync()
    Coalescer-->>XTerm: remaining bytes written
    Transport->>Transport: cleanup socket

    Transport->>Transport: disconnect() or disposeTransport()
    Transport->>Coalescer: dispose()
    Coalescer->>Coalescer: flushSync() (final write)
    Coalescer->>Coalescer: mark disposed (reject future pushes)
Loading

Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a WriteCoalescer module to the v2 WebSocket terminal path that batches PTY output chunks into a single xterm.write() per animation frame, eliminating the per-chunk parse/render cycles that caused visible scrolling lag when running streaming TUI tools like Claude Code.

  • write-coalescer.ts (new): a self-contained closure-based buffer that queues Uint8Array chunks, drains them once per requestAnimationFrame, and provides a synchronous flushSync() / dispose() for ordering guarantees and teardown.
  • terminal-ws-transport.ts: the coalescer is wired in at connect() (one instance per connection), binary WebSocket frames are routed through push(), and flushSync() is called before any JSON control message and on socket close to preserve terminal output ordering.
  • Both files ship with thorough unit and integration tests; the coalescer unit tests use a correct per-ID cancelAnimationFrame mock while the transport-level mock uses a simpler array-clear approach that is correct for all current scenarios.

Confidence Score: 4/5

Safe to merge. The coalescer is a well-scoped addition: it only affects how bytes reach xterm, all teardown paths flush correctly, and the behaviour is thoroughly exercised by both unit and transport-level tests.

The core WriteCoalescer logic and integration are correct across rAF lifecycle, byte-cap flush, dispose idempotency, and reconnect paths. Two small quality gaps exist: the transport test cancelAnimationFrame mock clears all callbacks by resetting the array instead of deleting by ID, and flushSync() is called unconditionally before every JSON control message rather than only the branches that write to the terminal.

The cancelAnimationFrame mock in terminal-ws-transport.test.ts and the unconditional flushSync() before all JSON control messages in terminal-ws-transport.ts are worth a second look, but neither affects production correctness.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/lib/terminal/write-coalescer.ts New module: buffers Uint8Array chunks, drains into a single write() per rAF, with synchronous flush-and-cancel on flushSync(), a 1MB byte cap, and idempotent dispose(). No issues found.
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts Wires WriteCoalescer into the transport correctly across connect/disconnect/reconnect/liveness-watchdog paths. Minor: flushSync() is called before non-terminal-writing messages, causing unnecessary early flushes.
apps/desktop/src/renderer/lib/terminal/write-coalescer.test.ts 7 unit tests covering coalescing, re-scheduling, flushSync semantics, empty-flush no-op, byte-cap trigger, dispose semantics, and byte-order preservation. Uses correct Map-based rAF mock.
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts Adds 3 transport-level coalescing tests. Tests are correct but the cancelAnimationFrame mock clears all callbacks rather than only the one matching the given ID.

Sequence Diagram

sequenceDiagram
    participant WS as WebSocket
    participant TWT as terminal-ws-transport
    participant WC as WriteCoalescer
    participant RAF as requestAnimationFrame
    participant XTerm as xterm.js

    WS->>TWT: binary frame (PTY bytes)
    TWT->>WC: push(Uint8Array)
    WC->>RAF: schedule frame (if not already scheduled)
    Note over WC: pending chunks accumulate

    WS->>TWT: binary frame (PTY bytes)
    TWT->>WC: push(Uint8Array)
    Note over WC: appended to pending

    RAF->>WC: frame callback fires
    WC->>WC: cancel frameId, concat chunks
    WC->>XTerm: write(combined Uint8Array)

    alt JSON control message arrives
        WS->>TWT: JSON frame (exit/title/attached/...)
        TWT->>WC: flushSync() [cancel rAF, write pending immediately]
        WC->>XTerm: write(pending bytes)
        TWT->>XTerm: writeln(exit notice) / other control action
    end

    alt Socket closes
        WS->>TWT: close event
        TWT->>WC: flushSync()
        WC->>XTerm: write(pending bytes)
    end

    alt disconnect() / disposeTransport() / reconnect via connect()
        TWT->>WC: "dispose() -> flushSync() + mark disposed"
        WC->>XTerm: write(any remaining bytes)
        Note over WC: future push() calls silently dropped
    end
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts:143-145
**`cancelAnimationFrame` mock ignores the ID and clears all callbacks**

The mock resets the entire `frameCallbacks` array on every `cancelAnimationFrame` call regardless of which frame ID was passed. The real API only cancels the one frame identified by the given ID. The coalescer enforces a single-pending-rAF invariant today, so no current test is wrong. But if a future test exercises a scenario where two callbacks happen to be queued simultaneously, the mock would incorrectly cancel the unrelated callback and the test would silently produce a false pass. The write-coalescer unit tests already use the correct `Map`-based mock — using the same pattern here avoids the fragility.

### Issue 2 of 2
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts:439-441
**`flushSync` called unconditionally before all JSON messages, including non-writing ones**

`flushSync()` is called for every JSON control message — including `attached` and `title`, which never write to the terminal. The ordering guarantee is only actually needed before messages that call `terminal.writeln` (the `exit` branch and the JSON-parse-failure path). PTY chunks arriving in the same event-loop turn as an `attached` acknowledgment (e.g., end of a replay burst) get flushed a frame early, defeating coalescing for that batch. Deferring the guard to only the branches that write to the terminal would preserve ordering where it matters while allowing fully-coalesced delivery for the `title`/`attached`/`error` branches.

Reviews (1): Last reviewed commit: "perf(terminal): coalesce PTY output into..." | Re-trigger Greptile

Comment on lines +143 to +145
};
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 cancelAnimationFrame mock ignores the ID and clears all callbacks

The mock resets the entire frameCallbacks array on every cancelAnimationFrame call regardless of which frame ID was passed. The real API only cancels the one frame identified by the given ID. The coalescer enforces a single-pending-rAF invariant today, so no current test is wrong. But if a future test exercises a scenario where two callbacks happen to be queued simultaneously, the mock would incorrectly cancel the unrelated callback and the test would silently produce a false pass. The write-coalescer unit tests already use the correct Map-based mock — using the same pattern here avoids the fragility.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts
Line: 143-145

Comment:
**`cancelAnimationFrame` mock ignores the ID and clears all callbacks**

The mock resets the entire `frameCallbacks` array on every `cancelAnimationFrame` call regardless of which frame ID was passed. The real API only cancels the one frame identified by the given ID. The coalescer enforces a single-pending-rAF invariant today, so no current test is wrong. But if a future test exercises a scenario where two callbacks happen to be queued simultaneously, the mock would incorrectly cancel the unrelated callback and the test would silently produce a false pass. The write-coalescer unit tests already use the correct `Map`-based mock — using the same pattern here avoids the fragility.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +439 to +441
// Control messages may write to the terminal (exit notice, invalid
// payload); flush queued PTY bytes first so output stays ordered.
transport._writeCoalescer?.flushSync();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 flushSync called unconditionally before all JSON messages, including non-writing ones

flushSync() is called for every JSON control message — including attached and title, which never write to the terminal. The ordering guarantee is only actually needed before messages that call terminal.writeln (the exit branch and the JSON-parse-failure path). PTY chunks arriving in the same event-loop turn as an attached acknowledgment (e.g., end of a replay burst) get flushed a frame early, defeating coalescing for that batch. Deferring the guard to only the branches that write to the terminal would preserve ordering where it matters while allowing fully-coalesced delivery for the title/attached/error branches.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Line: 439-441

Comment:
**`flushSync` called unconditionally before all JSON messages, including non-writing ones**

`flushSync()` is called for every JSON control message — including `attached` and `title`, which never write to the terminal. The ordering guarantee is only actually needed before messages that call `terminal.writeln` (the `exit` branch and the JSON-parse-failure path). PTY chunks arriving in the same event-loop turn as an `attached` acknowledgment (e.g., end of a replay burst) get flushed a frame early, defeating coalescing for that batch. Deferring the guard to only the branches that write to the terminal would preserve ordering where it matters while allowing fully-coalesced delivery for the `title`/`attached`/`error` branches.

How can I resolve this? If you propose a fix, please make it concise.

@Kitenite
Kitenite merged commit 1c558dc into ParadevAI:main Jun 22, 2026
2 checks passed
@Kitenite

Copy link
Copy Markdown
Member

Thank you so much @thebrownproject !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants